Boazreicher/add suggest to labels and issue fields (#2557)

* adding suggestions for labels and fields

* fixing suggestions

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* updaing

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Boaz Reicher
2026-05-28 12:29:34 +03:00
committed by GitHub
parent e091ea63d7
commit d661abf4ca
6 changed files with 251 additions and 68 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ runtime behavior (such as output formatting) won't appear here.
- **update_issue_type** - Update Issue Type
- **Required OAuth Scopes**: `repo`
- `is_suggestion`: If true, propose the issue type change instead of applying it. Defaults to false, which applies the change to the issue. (boolean, optional)
- `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional)
- `issue_number`: The issue number to update (number, required)
- `issue_type`: The issue type to set (string, required)
- `owner`: Repository owner (username or organization) (string, required)
@@ -23,6 +23,10 @@
"description": "The GraphQL node ID of the issue field",
"type": "string"
},
"is_suggestion": {
"description": "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the value is applied or recorded as a proposal is determined by the API.",
"type": "boolean"
},
"number_value": {
"description": "The value to set for a number field",
"type": "number"
@@ -22,6 +22,10 @@
},
{
"properties": {
"is_suggestion": {
"description": "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. Whether the label is applied or recorded as a proposal is determined by the API.",
"type": "boolean"
},
"name": {
"description": "Label name",
"type": "string"
@@ -8,7 +8,7 @@
"inputSchema": {
"properties": {
"is_suggestion": {
"description": "If true, propose the issue type change instead of applying it. Defaults to false, which applies the change to the issue.",
"description": "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API.",
"type": "boolean"
},
"issue_number": {
+200 -34
View File
@@ -2,7 +2,6 @@ package github
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
@@ -336,6 +335,84 @@ func TestGranularUpdateIssueLabels(t *testing.T) {
}
}
func TestGranularUpdateIssueLabelsSuggest(t *testing.T) {
tests := []struct {
name string
requestArgs map[string]any
expectedReq map[string]any
}{
{
name: "single label suggested without rationale",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"labels": []any{
map[string]any{"name": "bug", "is_suggestion": true},
},
},
expectedReq: map[string]any{
"labels": []any{
map[string]any{"name": "bug", "suggest": true},
},
},
},
{
name: "suggested label with rationale",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"labels": []any{
map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "is_suggestion": true},
},
},
expectedReq: map[string]any{
"labels": []any{
map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "suggest": true},
},
},
},
{
name: "mix of plain, applied-with-rationale, and suggested labels",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"labels": []any{
"triage",
map[string]any{"name": "bug", "rationale": "Reports a crash when saving"},
map[string]any{"name": "needs-design", "is_suggestion": true},
},
},
expectedReq: map[string]any{
"labels": []any{
"triage",
map[string]any{"name": "bug", "rationale": "Reports a crash when saving"},
map[string]any{"name": "needs-design", "suggest": true},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
}))
deps := BaseDeps{Client: client}
serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError)
})
}
}
func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) {
tests := []struct {
name string
@@ -463,62 +540,58 @@ func TestGranularUpdateIssueTypeSuggest(t *testing.T) {
tests := []struct {
name string
requestArgs map[string]any
expected map[string]any
expectedReq map[string]any
}{
{
name: "suggest without rationale",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
"suggest": true,
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
"is_suggestion": true,
},
expected: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
"suggested": true,
expectedReq: map[string]any{
"type": map[string]any{
"value": "bug",
"suggest": true,
},
},
},
{
name: "suggest with rationale",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": " Asks for dark mode support ",
"suggest": true,
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": " Asks for dark mode support ",
"is_suggestion": true,
},
expected: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": "Asks for dark mode support",
"suggested": true,
expectedReq: map[string]any{
"type": map[string]any{
"value": "feature",
"rationale": "Asks for dark mode support",
"suggest": true,
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// No HTTP handler registered: any API call would fail the test.
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
}))
deps := BaseDeps{Client: client}
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var got map[string]any
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &got))
assert.Equal(t, tc.expected, got)
assert.False(t, result.IsError)
})
}
}
@@ -1312,4 +1385,97 @@ func TestGranularSetIssueFields(t *testing.T) {
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "field rationale must be 280 characters or less")
})
t.Run("successful set with suggest flag", func(t *testing.T) {
suggestTrue := githubv4.Boolean(true)
matchers := []githubv4mock.Matcher{
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(5),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{"id": "ISSUE_123"},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
SetIssueFieldValue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
}
IssueFieldValues []struct {
TextValue struct {
Value string
} `graphql:"... on IssueFieldTextValue"`
SingleSelectValue struct {
Name string
} `graphql:"... on IssueFieldSingleSelectValue"`
DateValue struct {
Value string
} `graphql:"... on IssueFieldDateValue"`
NumberValue struct {
Value float64
} `graphql:"... on IssueFieldNumberValue"`
}
} `graphql:"setIssueFieldValue(input: $input)"`
}{},
SetIssueFieldValueInput{
IssueID: githubv4.ID("ISSUE_123"),
IssueFields: []IssueFieldCreateOrUpdateInput{
{
FieldID: githubv4.ID("FIELD_1"),
TextValue: githubv4.NewString(githubv4.String("hello")),
Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")),
Suggest: &suggestTrue,
},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"setIssueFieldValue": map[string]any{
"issue": map[string]any{
"id": "ISSUE_123",
"number": 5,
"url": "https://github.com/owner/repo/issues/5",
},
},
}),
),
}
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
deps := BaseDeps{GQLClient: gqlClient}
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(5),
"fields": []any{
map[string]any{
"field_id": "FIELD_1",
"text_value": "hello",
"rationale": "Reflects the reported severity",
"is_suggestion": true,
},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError)
})
}
+41 -32
View File
@@ -259,10 +259,11 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor
}
// labelWithRationale represents the object form of a label entry, allowing a
// rationale to be sent alongside the label name.
// rationale and/or suggest flag to be sent alongside the label name.
type labelWithRationale struct {
Name string `json:"name"`
Rationale string `json:"rationale,omitempty"`
Suggest bool `json:"suggest,omitempty"`
}
// labelsUpdateRequest is a custom request body for updating an issue's labels
@@ -320,6 +321,11 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
"State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
MaxLength: jsonschema.Ptr(280),
},
"is_suggestion": {
Type: "boolean",
Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " +
"Whether the label is applied or recorded as a proposal is determined by the API.",
},
},
Required: []string{"name"},
},
@@ -362,7 +368,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
}
}
anyRationale := false
useObjectForm := false
payload := make([]any, 0, len(labelsSlice))
for _, item := range labelsSlice {
switch v := item.(type) {
@@ -381,14 +387,18 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil
}
if rationale == "" {
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if rationale == "" && !isSuggestion {
payload = append(payload, name)
} else {
anyRationale = true
payload = append(payload, labelWithRationale{Name: name, Rationale: rationale})
useObjectForm = true
payload = append(payload, labelWithRationale{Name: name, Rationale: rationale, Suggest: isSuggestion})
}
default:
return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale'"), nil, nil
return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale' and/or 'is_suggestion'"), nil, nil
}
}
@@ -398,10 +408,10 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
}
var body any
if anyRationale {
if useObjectForm {
body = &labelsUpdateRequest{Labels: payload}
} else {
// Preserve the standard wire format when no rationale is supplied.
// Preserve the standard wire format when no rationale or suggest is supplied.
names := make([]string, len(payload))
for i, p := range payload {
names[i] = p.(string)
@@ -461,10 +471,11 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor
}
// issueTypeWithRationale represents the object form of the issue type field,
// allowing a rationale to be sent alongside the type name.
// allowing a rationale and/or suggest flag to be sent alongside the type name.
type issueTypeWithRationale struct {
Value string `json:"value"`
Rationale string `json:"rationale"`
Rationale string `json:"rationale,omitempty"`
Suggest bool `json:"suggest,omitempty"`
}
// issueTypeUpdateRequest is a custom request body for updating an issue type
@@ -514,8 +525,8 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
},
"is_suggestion": {
Type: "boolean",
Description: "If true, propose the issue type change instead of applying it. " +
"Defaults to false, which applies the change to the issue.",
Description: "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
"Whether the type is applied or recorded as a proposal is determined by the API.",
},
},
Required: []string{"owner", "repo", "issue_number", "issue_type"},
@@ -547,40 +558,23 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
}
suggest, err := OptionalParam[bool](args, "suggest")
isSuggestion, err := OptionalParam[bool](args, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if suggest {
suggestion := map[string]any{
"owner": owner,
"repo": repo,
"issue_number": issueNumber,
"issue_type": issueType,
"suggested": true,
}
if rationale != "" {
suggestion["rationale"] = rationale
}
r, err := json.Marshal(suggestion)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal suggestion", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
var body any
if rationale != "" {
if rationale != "" || isSuggestion {
body = &issueTypeUpdateRequest{
Type: issueTypeWithRationale{
Value: issueType,
Rationale: rationale,
Suggest: isSuggestion,
},
}
} else {
@@ -893,6 +887,7 @@ type IssueFieldCreateOrUpdateInput struct {
SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"`
Delete *githubv4.Boolean `json:"delete,omitempty"`
Rationale *githubv4.String `json:"rationale,omitempty"`
Suggest *githubv4.Boolean `json:"suggest,omitempty"`
}
// GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL.
@@ -961,6 +956,11 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
"State the concrete signal (e.g. 'Reports a crash when saving' → high priority).",
MaxLength: jsonschema.Ptr(280),
},
"is_suggestion": {
Type: "boolean",
Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
"Whether the value is applied or recorded as a proposal is determined by the API.",
},
},
Required: []string{"field_id"},
},
@@ -1073,6 +1073,15 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
}
}
isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if isSuggestion {
suggestVal := githubv4.Boolean(true)
input.Suggest = &suggestVal
}
issueFields = append(issueFields, input)
}