Skip to content

Commit c1b9629

Browse files
authored
Support field-value intent (rationale/confidence/suggestion) in issue_write
1 parent 3d240f4 commit c1b9629

3 files changed

Lines changed: 285 additions & 15 deletions

File tree

pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@
3434
"items": {
3535
"additionalProperties": false,
3636
"properties": {
37+
"confidence": {
38+
"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.",
39+
"enum": [
40+
"low",
41+
"medium",
42+
"high"
43+
],
44+
"type": "string"
45+
},
3746
"delete": {
3847
"description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value' or 'field_option_name'.",
3948
"enum": [
@@ -49,6 +58,15 @@
4958
"description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete'.",
5059
"type": "string"
5160
},
61+
"is_suggestion": {
62+
"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.",
63+
"type": "boolean"
64+
},
65+
"rationale": {
66+
"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).",
67+
"maxLength": 280,
68+
"type": "string"
69+
},
5270
"value": {
5371
"description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete'.",
5472
"type": [

pkg/github/issues.go

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ type issueWriteFieldInput struct {
4545
Value any
4646
FieldOptionName string
4747
Delete bool
48+
// Intent carries optional rationale/confidence/suggestion metadata for a
49+
// value-setting field. It is ignored for delete entries and on create.
50+
Intent valueIntent
4851
}
4952

5053
const (
@@ -290,19 +293,30 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro
290293
return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName)
291294
}
292295

296+
intent, _, err := parseValueIntent(itemMap)
297+
if err != nil {
298+
return nil, err
299+
}
300+
293301
issueFields = append(issueFields, issueWriteFieldInput{
294302
FieldName: fieldName,
295303
Value: value,
296304
FieldOptionName: fieldOptionName,
305+
Intent: intent,
297306
})
298307
}
299308

300309
return issueFields, nil
301310
}
302311

303-
func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, error) {
312+
// resolveIssueRequestFieldValues resolves user-friendly field inputs into REST
313+
// IssueRequestFieldValue entries (field IDs and option values resolved). It also
314+
// returns the IDs of fields marked for deletion and a map of field ID to intent
315+
// metadata for fields that carried rationale/confidence/suggestion intent, so the
316+
// caller can send those values in object form.
317+
func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, map[int64]valueIntent, error) {
304318
if len(issueFields) == 0 {
305-
return nil, nil, nil
319+
return nil, nil, nil, nil
306320
}
307321

308322
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
@@ -312,7 +326,7 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
312326
"repo": githubv4.String(repo),
313327
}
314328
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
315-
return nil, nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
329+
return nil, nil, nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
316330
}
317331

318332
// Build name → node map, dispatching on concrete type to extract name.
@@ -336,10 +350,11 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
336350

337351
resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields))
338352
var fieldIDsToDelete []int64
353+
var fieldIntents map[int64]valueIntent
339354
for _, fieldInput := range issueFields {
340355
node, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))]
341356
if !ok {
342-
return nil, nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo)
357+
return nil, nil, nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo)
343358
}
344359

345360
var fullDatabaseIDStr, dataType string
@@ -360,7 +375,7 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
360375

361376
fieldID := parseFullDatabaseID(fullDatabaseIDStr)
362377
if fieldID == 0 {
363-
return nil, nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName)
378+
return nil, nil, nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName)
364379
}
365380

366381
if fieldInput.Delete {
@@ -371,7 +386,7 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
371386
resolvedValue := fieldInput.Value
372387
if fieldInput.FieldOptionName != "" {
373388
if !strings.EqualFold(dataType, "single_select") {
374-
return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType)
389+
return nil, nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType)
375390
}
376391

377392
optionFound := false
@@ -385,17 +400,24 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
385400
}
386401

387402
if !optionFound {
388-
return nil, nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName)
403+
return nil, nil, nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName)
389404
}
390405
}
391406

407+
if fieldInput.Intent.HasIntent() {
408+
if fieldIntents == nil {
409+
fieldIntents = make(map[int64]valueIntent)
410+
}
411+
fieldIntents[fieldID] = fieldInput.Intent
412+
}
413+
392414
resolved = append(resolved, &github.IssueRequestFieldValue{
393415
FieldID: fieldID,
394416
Value: resolvedValue,
395417
})
396418
}
397419

398-
return resolved, fieldIDsToDelete, nil
420+
return resolved, fieldIDsToDelete, fieldIntents, nil
399421
}
400422

401423
// fetchExistingIssueFieldValues retrieves the current field values for an issue
@@ -1922,7 +1944,7 @@ Options are:
19221944
Items: &jsonschema.Schema{
19231945
Type: "object",
19241946
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
1925-
Properties: map[string]*jsonschema.Schema{
1947+
Properties: withIntentProperties(map[string]*jsonschema.Schema{
19261948
"field_name": {
19271949
Type: "string",
19281950
Description: "Issue field name (case-insensitive). Must match a field " +
@@ -1947,7 +1969,7 @@ Options are:
19471969
Description: "Set to true to clear this field's current value on the " +
19481970
"issue. Cannot be combined with 'value' or 'field_option_name'.",
19491971
},
1950-
},
1972+
}),
19511973
Required: []string{"field_name"},
19521974
},
19531975
},
@@ -2068,8 +2090,9 @@ Options are:
20682090

20692091
var issueFieldValues []*github.IssueRequestFieldValue
20702092
var fieldIDsToDelete []int64
2093+
var fieldValuesWithIntent map[int64]valueIntent
20712094
if len(issueFields) > 0 {
2072-
issueFieldValues, fieldIDsToDelete, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
2095+
issueFieldValues, fieldIDsToDelete, fieldValuesWithIntent, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
20732096
if err != nil {
20742097
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
20752098
}
@@ -2094,6 +2117,9 @@ Options are:
20942117
if issueTypeHasIntent {
20952118
updateOpts.TypeWithIntent = issueTypePayload
20962119
}
2120+
if len(fieldValuesWithIntent) > 0 {
2121+
updateOpts.FieldValuesWithIntent = fieldValuesWithIntent
2122+
}
20972123
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, updateOpts)
20982124
return result, nil, err
20992125
default:
@@ -2412,6 +2438,10 @@ type UpdateIssueOptions struct {
24122438
// intent are preserved. When set, it takes precedence over the issueType
24132439
// string.
24142440
TypeWithIntent any
2441+
// FieldValuesWithIntent, when non-empty, maps a field ID to the intent
2442+
// metadata supplied for it, so those field values are sent in object form
2443+
// (field_id, value, plus rationale/confidence/suggest) via a custom request.
2444+
FieldValuesWithIntent map[int64]valueIntent
24152445
}
24162446

24172447
// issueRequestWithIntentOverrides marshals an IssueRequest into a generic map and
@@ -2431,6 +2461,33 @@ func issueRequestWithIntentOverrides(issueRequest *github.IssueRequest, override
24312461
return payload, nil
24322462
}
24332463

2464+
// issueFieldValueWithIntent is the object form of an issue field value: its
2465+
// field ID and value alongside optional intent metadata. It marshals to a single
2466+
// object merging field_id and value with the embedded
2467+
// rationale/confidence/suggest fields, mirroring how labels and types carry
2468+
// intent on the wire.
2469+
type issueFieldValueWithIntent struct {
2470+
FieldID int64
2471+
Value any
2472+
valueIntent
2473+
}
2474+
2475+
// MarshalJSON renders the value as a single object with field_id and value
2476+
// alongside the embedded intent metadata.
2477+
func (v issueFieldValueWithIntent) MarshalJSON() ([]byte, error) {
2478+
data, err := json.Marshal(v.valueIntent)
2479+
if err != nil {
2480+
return nil, err
2481+
}
2482+
obj := map[string]any{}
2483+
if err := json.Unmarshal(data, &obj); err != nil {
2484+
return nil, err
2485+
}
2486+
obj["field_id"] = v.FieldID
2487+
obj["value"] = v.Value
2488+
return json.Marshal(obj)
2489+
}
2490+
24342491
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) {
24352492
updateOptions := UpdateIssueOptions{
24362493
AssigneesProvided: len(assignees) > 0,
@@ -2445,6 +2502,9 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
24452502
if opt.TypeWithIntent != nil {
24462503
updateOptions.TypeWithIntent = opt.TypeWithIntent
24472504
}
2505+
if len(opt.FieldValuesWithIntent) > 0 {
2506+
updateOptions.FieldValuesWithIntent = opt.FieldValuesWithIntent
2507+
}
24482508
}
24492509

24502510
// Create the issue request with only provided fields
@@ -2505,17 +2565,29 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
25052565
var updatedIssue *github.Issue
25062566
var resp *github.Response
25072567
var err error
2508-
if len(updateOptions.LabelsWithIntent) > 0 || updateOptions.TypeWithIntent != nil {
2509-
// Send values that carry intent (labels and/or type) in object form so
2510-
// their rationale and suggestion intent are preserved. Marshal the standard
2511-
// request (those fields omitted), then inject the object-form values.
2568+
if len(updateOptions.LabelsWithIntent) > 0 || updateOptions.TypeWithIntent != nil || len(updateOptions.FieldValuesWithIntent) > 0 {
2569+
// Send values that carry intent (labels, type, and/or field values) in
2570+
// object form so their rationale and suggestion intent are preserved.
2571+
// Marshal the standard request (those fields omitted), then inject the
2572+
// object-form values.
25122573
overrides := map[string]any{}
25132574
if len(updateOptions.LabelsWithIntent) > 0 {
25142575
overrides["labels"] = updateOptions.LabelsWithIntent
25152576
}
25162577
if updateOptions.TypeWithIntent != nil {
25172578
overrides["type"] = updateOptions.TypeWithIntent
25182579
}
2580+
if len(updateOptions.FieldValuesWithIntent) > 0 && len(issueRequest.IssueFieldValues) > 0 {
2581+
fieldValues := make([]any, 0, len(issueRequest.IssueFieldValues))
2582+
for _, v := range issueRequest.IssueFieldValues {
2583+
if intent, ok := updateOptions.FieldValuesWithIntent[v.FieldID]; ok {
2584+
fieldValues = append(fieldValues, issueFieldValueWithIntent{FieldID: v.FieldID, Value: v.Value, valueIntent: intent})
2585+
} else {
2586+
fieldValues = append(fieldValues, map[string]any{"field_id": v.FieldID, "value": v.Value})
2587+
}
2588+
}
2589+
overrides["issue_field_values"] = fieldValues
2590+
}
25192591
payload, mErr := issueRequestWithIntentOverrides(issueRequest, overrides)
25202592
if mErr != nil {
25212593
return utils.NewToolResultErrorFromErr("failed to build issue update request", mErr), nil

0 commit comments

Comments
 (0)