diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 835179532..b51ee8594 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -16,6 +16,13 @@ const FeatureFlagIFCLabels = "ifc_labels" // and field_values enrichment in list_issues / search_issues output. const FeatureFlagIssueFields = "remote_mcp_issue_fields" +// FeatureFlagIssueFieldsUseIntent is the feature flag name for sending the +// rationale and confidence on issue field mutations as a nested `intent` +// object (IssueUpdateIntent) instead of as flat fields. It exists so the +// set_issue_fields tool can switch payload shapes while the backend migrates +// IssueFieldCreateOrUpdateInput from IssueEventRationale to IssueUpdateIntent. +const FeatureFlagIssueFieldsUseIntent = "issue_fields_use_intent" + // FeatureFlagFileBlame is the feature flag name for the get_file_blame tool, // which exposes git blame information for a file. It is gated so the extra tool // is not advertised by default, keeping the tool surface small unless opted in. diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 27e8079f9..6b906fb1a 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -1992,4 +1992,109 @@ func TestGranularSetIssueFields(t *testing.T) { // query does not require the feature flag. assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) }) + + t.Run("sends rationale and confidence as intent object when feature flag enabled", func(t *testing.T) { + confidence := "high" + 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 and confidence are nested under intent, + // while suggest stays a flat field. + Intent: &IssueUpdateIntentInput{ + Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")), + Confidence: &confidence, + }, + 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, + featureChecker: func(_ context.Context, flag string) (bool, error) { + return flag == FeatureFlagIssueFieldsUseIntent, nil + }, + } + 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", + "confidence": "high", + "is_suggestion": true, + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError, getTextResult(t, result).Text) + }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 3ddfd682f..62da364b7 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -917,6 +917,19 @@ type IssueFieldCreateOrUpdateInput struct { Rationale *githubv4.String `json:"rationale,omitempty"` Confidence *string `json:"confidence,omitempty"` Suggest *githubv4.Boolean `json:"suggest,omitempty"` + // Intent bundles rationale and confidence into a single object. It is + // the successor to the flat Rationale/Confidence fields above and is only + // sent when the FeatureFlagIssueFieldsUseIntent feature flag is enabled. + Intent *IssueUpdateIntentInput `json:"intent,omitempty"` +} + +// IssueUpdateIntentInput is the nested input object that carries the rationale +// and confidence for an issue field mutation. It mirrors the read-path +// IssueUpdateIntent type and replaces the flat rationale/confidence fields on +// IssueFieldCreateOrUpdateInput after the backend migration. +type IssueUpdateIntentInput struct { + Rationale *githubv4.String `json:"rationale,omitempty"` + Confidence *string `json:"confidence,omitempty"` } // GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL. @@ -1044,6 +1057,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv } issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps)) + // The backend is migrating the rationale/confidence pair on + // IssueFieldCreateOrUpdateInput from flat fields to a nested + // `intent` object (IssueUpdateIntent). While that migration is in + // flight, gate the payload shape behind a feature flag so we can + // switch over without breaking the un-migrated schema. + useIntentInput := deps.IsFeatureEnabled(ctx, FeatureFlagIssueFieldsUseIntent) for _, fieldMap := range fieldMaps { fieldID, err := RequiredParam[string](fieldMap, "field_id") if err != nil { @@ -1093,6 +1112,9 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil } + var rationalePtr *githubv4.String + var confidencePtr *string + if _, exists := fieldMap["rationale"]; exists { rationale, err := OptionalParam[string](fieldMap, "rationale") if err != nil { @@ -1103,7 +1125,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError("field rationale must be 280 characters or less"), nil, nil } if rationale != "" { - input.Rationale = githubv4.NewString(githubv4.String(rationale)) + rationalePtr = githubv4.NewString(githubv4.String(rationale)) } } @@ -1115,7 +1137,22 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil } if confidence != "" { - input.Confidence = &confidence + confidencePtr = &confidence + } + + // Send rationale and confidence either as the nested `intent` + // object (new schema) or as flat fields (old schema), depending + // on the feature flag. + if useIntentInput { + if rationalePtr != nil || confidencePtr != nil { + input.Intent = &IssueUpdateIntentInput{ + Rationale: rationalePtr, + Confidence: confidencePtr, + } + } + } else { + input.Rationale = rationalePtr + input.Confidence = confidencePtr } isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion")