Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,7 @@ The following sets of tools are available:
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `labels`: Labels to apply to this issue ([], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
Expand Down
4 changes: 2 additions & 2 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ runtime behavior (such as output formatting) won't appear here.
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `labels`: Labels to apply to this issue ([], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
Expand All @@ -80,7 +80,7 @@ runtime behavior (such as output formatting) won't appear here.
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `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)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `labels`: Labels to apply to this issue ([], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
Expand Down
4 changes: 2 additions & 2 deletions docs/insiders-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `labels`: Labels to apply to this issue ([], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
Expand All @@ -74,7 +74,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `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)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
- `labels`: Labels to apply to this issue ([], optional)
- `method`: Write operation to perform on a single issue.
Options are:
- 'create' - creates a new issue.
Expand Down
37 changes: 36 additions & 1 deletion pkg/github/__toolsnaps__/issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,42 @@
"labels": {
"description": "Labels to apply to this issue",
"items": {
"type": "string"
"oneOf": [
{
"description": "Label name",
"type": "string"
},
{
"properties": {
"confidence": {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@boazreicher I added confidence with values low, medium and high, and without any optional param or anything special, just sending it to the server

"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.",
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"is_suggestion": {
"description": "If true, this value is sent to the API as a suggestion rather than an applied value. Whether it is applied or recorded as a proposal is determined by the API. Only honored when updating an existing issue.",
"type": "boolean"
},
"name": {
"description": "Label name",
"type": "string"
},
"rationale": {
"description": "A concise explanation of what specifically about the issue led you to this choice. State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
"maxLength": 280,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
}
]
},
"type": "array"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,42 @@
"labels": {
"description": "Labels to apply to this issue",
"items": {
"type": "string"
"oneOf": [
{
"description": "Label name",
"type": "string"
},
{
"properties": {
"confidence": {
"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.",
"enum": [
"low",
"medium",
"high"
],
"type": "string"
},
"is_suggestion": {
"description": "If true, this value is sent to the API as a suggestion rather than an applied value. Whether it is applied or recorded as a proposal is determined by the API. Only honored when updating an existing issue.",
"type": "boolean"
},
"name": {
"description": "Label name",
"type": "string"
},
"rationale": {
"description": "A concise explanation of what specifically about the issue led you to this choice. State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
"maxLength": 280,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
}
]
},
"type": "array"
},
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/granular_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) {
map[string]any{"name": "bug", "rationale": strings.Repeat("a", 281)},
},
},
expectedErrText: "label rationale must be 280 characters or less",
expectedErrText: "rationale must be 280 characters or less",
},
{
name: "label object missing name",
Expand All @@ -441,7 +441,7 @@ func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) {
map[string]any{"rationale": "no name provided"},
},
},
expectedErrText: "each label object must have a 'name' string",
expectedErrText: "each labels object must have a 'name' string",
},
}

Expand Down
109 changes: 93 additions & 16 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1858,7 +1858,19 @@ Options are:
Type: "array",
Description: "Labels to apply to this issue",
Items: &jsonschema.Schema{
Comment on lines 1858 to 1860
Type: "string",
OneOf: []*jsonschema.Schema{
{Type: "string", Description: "Label name"},
{
Type: "object",
Properties: withIntentProperties(map[string]*jsonschema.Schema{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@boazreicher @alondahari , I extracted the parser and also the intent related props in their own type, and using it to extend labels (and soon, types and field values) ; also using this in the issues_granular

"name": {
Type: "string",
Description: "Label name",
},
}),
Required: []string{"name"},
},
},
},
},
"milestone": {
Expand Down Expand Up @@ -1975,13 +1987,11 @@ Options are:
assigneesValue, assigneesProvided := args["assignees"]
assigneesProvided = assigneesProvided && assigneesValue != nil

// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
// Get labels (plain names or per-label intent objects)
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseParamWithIntent(args, "labels", "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
labelsValue, labelsProvided := args["labels"]
labelsProvided = labelsProvided && labelsValue != nil

// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
Expand Down Expand Up @@ -2053,10 +2063,14 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
updateOpts := UpdateIssueOptions{
AssigneesProvided: assigneesProvided,
LabelsProvided: labelsProvided,
})
}
if labelsHaveIntent {
updateOpts.LabelsWithIntent = labelsPayload
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, updateOpts)
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
Expand Down Expand Up @@ -2132,7 +2146,19 @@ Options are:
Type: "array",
Description: "Labels to apply to this issue",
Items: &jsonschema.Schema{
Comment on lines 2146 to 2148
Type: "string",
OneOf: []*jsonschema.Schema{
{Type: "string", Description: "Label name"},
{
Type: "object",
Properties: withIntentProperties(map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "Label name",
},
}),
Required: []string{"name"},
},
},
},
},
"milestone": {
Expand Down Expand Up @@ -2214,13 +2240,11 @@ Options are:
assigneesValue, assigneesProvided := args["assignees"]
assigneesProvided = assigneesProvided && assigneesValue != nil

// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
// Get labels (plain names or per-label intent objects)
labels, labelsPayload, labelsHaveIntent, labelsProvided, err := parseParamWithIntent(args, "labels", "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
labelsValue, labelsProvided := args["labels"]
labelsProvided = labelsProvided && labelsValue != nil

// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
Expand Down Expand Up @@ -2277,10 +2301,14 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{
updateOpts := UpdateIssueOptions{
AssigneesProvided: assigneesProvided,
LabelsProvided: labelsProvided,
})
}
if labelsHaveIntent {
updateOpts.LabelsWithIntent = labelsPayload
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, updateOpts)
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
Expand Down Expand Up @@ -2350,6 +2378,30 @@ type UpdateIssueOptions struct {
AssigneesProvided bool
// LabelsProvided sends the labels field even when the slice is empty.
LabelsProvided bool
// LabelsWithIntent, when non-empty, sends labels in object form (a mix of
// plain label names and valueWithIntent objects) via a custom request so
// per-label rationale and suggestion intent are preserved. When set, it
// takes precedence over the labels slice.
LabelsWithIntent []any
}

// issueRequestWithLabels marshals an IssueRequest into a generic map and sets
// the labels field to the provided object-form payload (a mix of plain label
// names and valueWithIntent objects). This lets an issue update carry per-label
// rationale and suggestion intent that github.IssueRequest cannot represent.
func issueRequestWithLabels(issueRequest *github.IssueRequest, labels []any) (map[string]any, error) {
data, err := json.Marshal(issueRequest)
if err != nil {
return nil, err
}
payload := map[string]any{}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return nil, err
}
payload["labels"] = labels
return payload, nil
}
Comment thread
Copilot marked this conversation as resolved.

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) {
Expand All @@ -2360,6 +2412,9 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
for _, opt := range opts {
updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided
updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided
if len(opt.LabelsWithIntent) > 0 {
updateOptions.LabelsWithIntent = opt.LabelsWithIntent
}
}

// Create the issue request with only provided fields
Expand All @@ -2374,7 +2429,9 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
issueRequest.Body = github.Ptr(body)
}

if updateOptions.LabelsProvided {
// When labels carry per-label intent, they are sent via a custom request
// below instead of through issueRequest.Labels.
if updateOptions.LabelsProvided && len(updateOptions.LabelsWithIntent) == 0 {
issueRequest.Labels = &labels
}

Expand Down Expand Up @@ -2415,7 +2472,27 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
issueRequest.IssueFieldValues = merged
}

updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
var updatedIssue *github.Issue
var resp *github.Response
var err error
if len(updateOptions.LabelsWithIntent) > 0 {
// Send labels in object form so per-label rationale and suggestion intent
// are preserved. Marshal the standard request (labels omitted), then inject
// the object-form labels into the payload.
payload, mErr := issueRequestWithLabels(issueRequest, updateOptions.LabelsWithIntent)
if mErr != nil {
return utils.NewToolResultErrorFromErr("failed to build issue update request", mErr), nil
}
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
httpReq, rErr := client.NewRequest(ctx, "PATCH", apiURL, payload)
if rErr != nil {
return utils.NewToolResultErrorFromErr("failed to create request", rErr), nil
}
updatedIssue = &github.Issue{}
resp, err = client.Do(httpReq, updatedIssue)
} else {
updatedIssue, resp, err = client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to update issue",
Expand Down
Loading
Loading